Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 514005e995d29a03df041d5a9772fa61b1664294


Parents : 5870af2
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-24T04:52:23-05:00

feat: add "Copy image to clipboard" functionality in message context menus and improve logging with broken-pipe guards

Changes
Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5788e1b7..bca78d16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,7 @@ All notable changes to this project will be documented in this file.
- Relay Chat room keys so hosts can require a key to join a room
- Desktop privacy: Windows screen security to omit MeshChatX from screenshots, recording, and Recall
- Android privacy options to block screenshots and clear the clipboard when backgrounded
+- Messages: **Copy image to clipboard** on the message and full-screen image context menus
- Remote management allow-list for identities that may query this instance with rnstatus/rnpath
- Post-install prompts for existing users after upgrades
- Coolify-oriented Docker Compose with resource limits for deployments
@@ -56,6 +57,7 @@ All notable changes to this project will be documented in this file.
### Fixed
+- Desktop AppImage: closed stdout no longer raises a main-process **write EPIPE** dialog when MeshChatX is backgrounded. Logging uses broken-pipe guards
- Android: lxmfy packaging, flock soft-lock, splash/logo clipping, Landlock skipped on Android
- Android RNode BLE/USB via Chaquopy
- Startup check and disable unsupported interfaces

diff --git a/electron/main.js b/electron/main.js
index a1215829..3b1b24f0 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -42,6 +42,9 @@ const {
applyContentProtection,
applyContentProtectionToWindows,
} = require("./desktopPrivacySettings");
+const { installBrokenPipeGuards, safeConsoleLog } = require("./safeConsole");
+
+installBrokenPipeGuards(process);
// remember main window
var mainWindow = null;
@@ -84,7 +87,7 @@ try {
const disableGpuFile = path.join(storageDir, "disable-gpu");
if (fs.existsSync(disableGpuFile)) {
app.disableHardwareAcceleration();
- console.log("Hardware acceleration disabled via storage flag.");
+ safeConsoleLog("Hardware acceleration disabled via storage flag.");
}
} catch {
// ignore errors reading storage dir this early
@@ -102,7 +105,7 @@ if (process.platform === "linux") {
// Detect if running in Flatpak sandbox
const isRunningInFlatpak = !!process.env.FLATPAK_ID;
if (isRunningInFlatpak) {
- console.log(`Running in Flatpak sandbox: ${process.env.FLATPAK_ID}`);
+ safeConsoleLog(`Running in Flatpak sandbox: ${process.env.FLATPAK_ID}`);
}
// Protocol registration
@@ -629,6 +632,26 @@ function attachDefaultContextMenu(browserWindow) {
});
}
+ if (params.mediaType === "image" || params.hasImageContents) {
+ if (template.length > 0) {
+ template.push({ type: "separator" });
+ }
+ template.push({
+ label: "Copy image",
+ click: () => {
+ webContents.copyImageAt(params.x, params.y);
+ },
+ });
+ if (params.srcURL) {
+ template.push({
+ label: "Copy image address",
+ click: () => {
+ clipboard.writeText(params.srcURL);
+ },
+ });
+ }
+ }
+
if (template.length === 0) {
return;
}
@@ -639,8 +662,8 @@ function attachDefaultContextMenu(browserWindow) {
}
function log(message) {
- // log to stdout of this process
- console.log(message);
+ // log to stdout of this process (AppImage may close the pipe later)
+ safeConsoleLog(message);
// make sure main window exists
if (!mainWindow) {

diff --git a/electron/safeConsole.js b/electron/safeConsole.js
new file mode 100644
index 00000000..68c6512e
--- /dev/null
+++ b/electron/safeConsole.js
@@ -0,0 +1,61 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Broken-pipe (EPIPE) guards for Electron AppImage / launcher sessions where
+ * stdout or stderr is closed while the main process still logs.
+ */
+
+function isBrokenPipeError(err) {
+ if (!err || typeof err !== "object") {
+ return false;
+ }
+ if (err.code === "EPIPE" || err.errno === "EPIPE") {
+ return true;
+ }
+ const message = typeof err.message === "string" ? err.message : "";
+ return /\bEPIPE\b/i.test(message);
+}
+
+/**
+ * Attach no-op error listeners so closed stdout/stderr do not become
+ * uncaughtException dialogs when console.log writes after the pipe closes.
+ * @param {{ stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream } | null | undefined} [proc]
+ */
+function installBrokenPipeGuards(proc = process) {
+ const attach = (stream) => {
+ if (!stream || typeof stream.on !== "function") {
+ return;
+ }
+ if (stream.__meshchatxBrokenPipeGuarded) {
+ return;
+ }
+ stream.__meshchatxBrokenPipeGuarded = true;
+ stream.on("error", (err) => {
+ if (isBrokenPipeError(err)) {
+ return;
+ }
+ });
+ };
+ attach(proc?.stdout);
+ attach(proc?.stderr);
+}
+
+/**
+ * console.log that never throws on a closed stdout pipe.
+ * @param {...unknown} args
+ */
+function safeConsoleLog(...args) {
+ try {
+ console.log(...args);
+ } catch (err) {
+ if (!isBrokenPipeError(err)) {
+ throw err;
+ }
+ }
+}
+
+module.exports = {
+ isBrokenPipeError,
+ installBrokenPipeGuards,
+ safeConsoleLog,
+};

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 495bdf4a..dbefb9ed 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 715053c8..adead7e3 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -968,6 +968,13 @@
<MaterialDesignIcon icon-name="download" class="size-4 text-blue-500" />
{{ $t("messages.save_image_to_device") }}
</ContextMenuItem>
+ <ContextMenuItem
+ v-if="messageContextMenu.chatItem?.lxmf_message?.fields?.image"
+ @click="copyMessageImageToClipboard(messageContextMenu.chatItem)"
+ >
+ <MaterialDesignIcon icon-name="content-copy" class="size-4 text-blue-500" />
+ {{ $t("messages.copy_image_to_clipboard") }}
+ </ContextMenuItem>
<ContextMenuItem
v-if="messageContextMenu.chatItem?.lxmf_message?.fields?.image"
@click="saveMessageImageToStickers(messageContextMenu.chatItem)"
@@ -1266,6 +1273,10 @@
<MaterialDesignIcon icon-name="download" class="size-4 text-blue-500" />
{{ $t("messages.save_image_to_device") }}
</ContextMenuItem>
+ <ContextMenuItem @click="copyImageModalCurrentToClipboard">
+ <MaterialDesignIcon icon-name="content-copy" class="size-4 text-blue-500" />
+ {{ $t("messages.copy_image_to_clipboard") }}
+ </ContextMenuItem>
</ContextMenuPanel>
</Teleport>
@@ -1646,7 +1657,7 @@
<script>
import Utils from "../../js/Utils";
-import { copyTextToClipboard, readTextFromClipboard } from "../../js/clipboardUtils.js";
+import { copyTextToClipboard, copyImageBlobToClipboard, readTextFromClipboard } from "../../js/clipboardUtils.js";
import { MESSAGE_BODY_MAX_DISPLAY_CHARS, isStringTooLargeForInlineDisplay } from "../../js/messageDisplayLimits.js";
import {
MAX_CODEC2_DECODED_RAW_BYTES,
@@ -4514,8 +4525,8 @@ export default {
return;
}
this.imageModalContextMenu.show = true;
- const menuWidth = 220;
- const menuHeight = 48;
+ const menuWidth = 240;
+ const menuHeight = 88;
let x = event.clientX;
let y = event.clientY;
if (x + menuWidth > window.innerWidth) {
@@ -4534,6 +4545,13 @@ export default {
this.downloadMessageImage(chatItem);
}
},
+ copyImageModalCurrentToClipboard() {
+ const chatItem = this.imageModalActiveChatItem();
+ this.imageModalContextMenu.show = false;
+ if (chatItem) {
+ void this.copyMessageImageToClipboard(chatItem);
+ }
+ },
imageModalNavigate(delta) {
if (!this.imageModalGallery || this.imageModalGallery.length < 2) return;
const n = this.imageModalGallery.length;
@@ -5283,6 +5301,47 @@ export default {
ToastUtils.error(this.$t("common.error"));
}
},
+ async resolveMessageImageBlob(chatItem) {
+ const msg = chatItem?.lxmf_message;
+ const img = msg?.fields?.image;
+ if (!msg?.hash || !img) {
+ return null;
+ }
+ const rawType = String(img.image_type || "png")
+ .replace(/^image\//, "")
+ .toLowerCase();
+ const mimeExt = rawType === "jpg" ? "jpeg" : rawType || "png";
+ const mime = `image/${mimeExt}`;
+ if (img.image_bytes) {
+ const bytes = this.base64ToArrayBuffer(img.image_bytes);
+ return new Blob([bytes], { type: mime });
+ }
+ const response = await window.api.get(`/api/v1/lxmf-messages/attachment/${msg.hash}/image`, {
+ responseType: "arraybuffer",
+ });
+ const headerType = response?.headers?.["content-type"] || response?.headers?.["Content-Type"];
+ const type =
+ typeof headerType === "string" && headerType.startsWith("image/") ? headerType.split(";")[0] : mime;
+ return new Blob([response.data], { type });
+ },
+ async copyMessageImageToClipboard(chatItem) {
+ this.messageContextMenu.show = false;
+ try {
+ const blob = await this.resolveMessageImageBlob(chatItem);
+ if (!blob) {
+ return;
+ }
+ const ok = await copyImageBlobToClipboard(blob);
+ if (!ok) {
+ ToastUtils.error(this.$t("messages.clipboard_write_unavailable"));
+ return;
+ }
+ ToastUtils.success(this.$t("messages.image_copied_to_clipboard"));
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("common.error"));
+ }
+ },
async processAudioForSelectedPeerChatItems() {
for (const chatItem of this.selectedPeerChatItems) {
// skip if no audio, or if audio bytes are missing (must be downloaded manually)

diff --git a/meshchatx/src/frontend/js/clipboardUtils.js b/meshchatx/src/frontend/js/clipboardUtils.js
index 752519dc..c9c5d76f 100644
--- a/meshchatx/src/frontend/js/clipboardUtils.js
+++ b/meshchatx/src/frontend/js/clipboardUtils.js
@@ -27,6 +27,20 @@ export function canUseAsyncClipboardRead() {
);
}
+/**
+ * Whether async clipboard image write is expected to work.
+ * @returns {boolean}
+ */
+export function canUseAsyncClipboardImageWrite() {
+ return (
+ typeof navigator !== "undefined" &&
+ !!navigator.clipboard &&
+ typeof navigator.clipboard.write === "function" &&
+ typeof ClipboardItem !== "undefined" &&
+ isWindowSecureContext()
+ );
+}
+
/**
* @param {string} text
* @returns {Promise<boolean>}
@@ -63,6 +77,94 @@ export async function copyTextToClipboard(text) {
}
}
+/**
+ * Copy an image Blob to the system clipboard (PNG preferred when conversion works).
+ * @param {Blob} blob
+ * @returns {Promise<boolean>}
+ */
+export async function copyImageBlobToClipboard(blob) {
+ if (!(blob instanceof Blob) || blob.size <= 0) {
+ return false;
+ }
+ if (!canUseAsyncClipboardImageWrite()) {
+ return false;
+ }
+ const type = blob.type && blob.type.startsWith("image/") ? blob.type : "image/png";
+ try {
+ await navigator.clipboard.write([new ClipboardItem({ [type]: blob })]);
+ return true;
+ } catch {
+ // Many hosts only accept image/png on the clipboard.
+ }
+ if (type === "image/png") {
+ return false;
+ }
+ try {
+ const pngBlob = await convertImageBlobToPng(blob);
+ if (!pngBlob) {
+ return false;
+ }
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": pngBlob })]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * @param {Blob} blob
+ * @returns {Promise<Blob | null>}
+ */
+async function convertImageBlobToPng(blob) {
+ if (typeof createImageBitmap === "function") {
+ try {
+ const bitmap = await createImageBitmap(blob);
+ const canvas = document.createElement("canvas");
+ canvas.width = bitmap.width;
+ canvas.height = bitmap.height;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ bitmap.close?.();
+ return null;
+ }
+ ctx.drawImage(bitmap, 0, 0);
+ bitmap.close?.();
+ return await new Promise((resolve) => {
+ canvas.toBlob((out) => resolve(out), "image/png");
+ });
+ } catch {
+ // fall through to HTMLImageElement path
+ }
+ }
+ if (typeof Image === "undefined" || typeof URL === "undefined") {
+ return null;
+ }
+ const objectUrl = URL.createObjectURL(blob);
+ try {
+ const img = await new Promise((resolve, reject) => {
+ const el = new Image();
+ el.onload = () => resolve(el);
+ el.onerror = () => reject(new Error("image_load_failed"));
+ el.src = objectUrl;
+ });
+ const canvas = document.createElement("canvas");
+ canvas.width = img.naturalWidth || img.width;
+ canvas.height = img.naturalHeight || img.height;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return null;
+ }
+ ctx.drawImage(img, 0, 0);
+ return await new Promise((resolve) => {
+ canvas.toBlob((out) => resolve(out), "image/png");
+ });
+ } catch {
+ return null;
+ } finally {
+ URL.revokeObjectURL(objectUrl);
+ }
+}
+
/**
* @returns {Promise<{ ok: true, text: string } | { ok: false, code: string }>}
*/

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 488686ac..401bf186 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1885,6 +1885,8 @@
"forward_open_party_is_self": "Das ist Ihre eigene Adresse.",
"message_actions": "Nachrichtenaktionen",
"save_image_to_device": "Bild auf Gerät speichern",
+ "copy_image_to_clipboard": "Bild in die Zwischenablage kopieren",
+ "image_copied_to_clipboard": "Bild in die Zwischenablage kopiert",
"cancel_send": "Senden abbrechen",
"react": "Reagieren",
"reaction_you": "Du",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 5f6b341a..9cde5b1b 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1837,6 +1837,8 @@
"forward_open_party_is_self": "That is your own address.",
"message_actions": "Message actions",
"save_image_to_device": "Save image to device",
+ "copy_image_to_clipboard": "Copy image to clipboard",
+ "image_copied_to_clipboard": "Image copied to clipboard",
"cancel_send": "Cancel send",
"react": "React",
"reaction_you": "You",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index c2bc6ab6..8030931f 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1833,6 +1833,8 @@
"forward_open_party_is_self": "Esa es tu propia dirección.",
"message_actions": "Acciones de mensajes",
"save_image_to_device": "Guardar imagen en el dispositivo",
+ "copy_image_to_clipboard": "Copiar imagen al portapapeles",
+ "image_copied_to_clipboard": "Imagen copiada al portapapeles",
"cancel_send": "Cancelar envío",
"react": "Reacción",
"reaction_you": "Tú.",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 188a19a9..63a4582b 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1833,6 +1833,8 @@
"forward_open_party_is_self": "Tämä on oma osoitteesi",
"message_actions": "Viestitoiminnot",
"save_image_to_device": "Tallenna kuva laitteelle",
+ "copy_image_to_clipboard": "Kopioi kuva leikepöydälle",
+ "image_copied_to_clipboard": "Kuva kopioitu leikepöydälle",
"cancel_send": "Peruuta lähetys",
"react": "Reagoi",
"reaction_you": "Sinä",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 936f3cf5..e1b96657 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1833,6 +1833,8 @@
"forward_open_party_is_self": "Il s'agit de votre propre adresse.",
"message_actions": "Actions du message",
"save_image_to_device": "Enregistrer l'image sur l'appareil",
+ "copy_image_to_clipboard": "Copier l'image dans le presse-papiers",
+ "image_copied_to_clipboard": "Image copiée dans le presse-papiers",
"cancel_send": "Annuler l'envoi",
"react": "Réagir",
"reaction_you": "Toi",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index f4bfad99..c49ca1ff 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1885,6 +1885,8 @@
"forward_open_party_is_self": "Questo è il tuo stesso indirizzo.",
"message_actions": "Azioni messaggio",
"save_image_to_device": "Salva immagine sul dispositivo",
+ "copy_image_to_clipboard": "Copia immagine negli appunti",
+ "image_copied_to_clipboard": "Immagine copiata negli appunti",
"cancel_send": "Annulla invio",
"react": "Reagisci",
"reaction_you": "Tu",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 940fc1fe..54df48f8 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1833,6 +1833,8 @@
"forward_open_party_is_self": "Dat is uw eigen adres.",
"message_actions": "Berichtacties",
"save_image_to_device": "Afbeelding opslaan op apparaat",
+ "copy_image_to_clipboard": "Afbeelding naar klembord kopiëren",
+ "image_copied_to_clipboard": "Afbeelding naar klembord gekopieerd",
"cancel_send": "Verzending annuleren",
"react": "Reageren",
"reaction_you": "Jij",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 6839c74b..737ff6e3 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1885,6 +1885,8 @@
"forward_open_party_is_self": "Это ваш собственный адрес.",
"message_actions": "Действия с сообщением",
"save_image_to_device": "Сохранить изображение на устройство",
+ "copy_image_to_clipboard": "Копировать изображение в буфер обмена",
+ "image_copied_to_clipboard": "Изображение скопировано в буфер обмена",
"cancel_send": "Отменить отправку",
"react": "Реакции",
"reaction_you": "Вы",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index e6749dea..e3a1b6dc 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1833,6 +1833,8 @@
"forward_open_party_is_self": "那是您自己的地址。",
"message_actions": "消息操作",
"save_image_to_device": "保存图片到设备",
+ "copy_image_to_clipboard": "复制图片到剪贴板",
+ "image_copied_to_clipboard": "图片已复制到剪贴板",
"cancel_send": "取消发送",
"react": "反应",
"reaction_you": "您",

diff --git a/tests/electron/safeConsole.test.js b/tests/electron/safeConsole.test.js
new file mode 100644
index 00000000..34f3fd29
--- /dev/null
+++ b/tests/electron/safeConsole.test.js
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: 0BSD
+
+import { createRequire } from "module";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const require = createRequire(import.meta.url);
+const {
+ isBrokenPipeError,
+ installBrokenPipeGuards,
+ safeConsoleLog,
+} = require("../../electron/safeConsole");
+
+describe("safeConsole", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("detects EPIPE by code and message", () => {
+ expect(isBrokenPipeError({ code: "EPIPE" })).toBe(true);
+ expect(isBrokenPipeError({ errno: "EPIPE" })).toBe(true);
+ expect(isBrokenPipeError(new Error("write EPIPE"))).toBe(true);
+ expect(isBrokenPipeError(new Error("other"))).toBe(false);
+ expect(isBrokenPipeError(null)).toBe(false);
+ });
+
+ it("installBrokenPipeGuards ignores EPIPE stream errors", () => {
+ const handlers = [];
+ const stream = {
+ on(event, handler) {
+ handlers.push([event, handler]);
+ },
+ };
+ installBrokenPipeGuards({ stdout: stream, stderr: null });
+ expect(handlers).toHaveLength(1);
+ expect(handlers[0][0]).toBe("error");
+ expect(() => handlers[0][1]({ code: "EPIPE" })).not.toThrow();
+ installBrokenPipeGuards({ stdout: stream, stderr: null });
+ expect(handlers).toHaveLength(1);
+ });
+
+ it("safeConsoleLog swallows EPIPE from console.log", () => {
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {
+ const err = new Error("write EPIPE");
+ err.code = "EPIPE";
+ throw err;
+ });
+ expect(() => safeConsoleLog("backend line")).not.toThrow();
+ expect(spy).toHaveBeenCalledWith("backend line");
+ });
+
+ it("safeConsoleLog rethrows non-EPIPE errors", () => {
+ vi.spyOn(console, "log").mockImplementation(() => {
+ throw new Error("boom");
+ });
+ expect(() => safeConsoleLog("x")).toThrow("boom");
+ });
+});

diff --git a/tests/frontend/clipboardUtils.test.js b/tests/frontend/clipboardUtils.test.js
index 9e392462..849c0d23 100644
--- a/tests/frontend/clipboardUtils.test.js
+++ b/tests/frontend/clipboardUtils.test.js
@@ -1,6 +1,8 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { describe, it, expect, vi, afterEach } from "vitest";
import {
copyTextToClipboard,
+ copyImageBlobToClipboard,
+ canUseAsyncClipboardImageWrite,
readTextFromClipboard,
isWindowSecureContext,
} from "../../meshchatx/src/frontend/js/clipboardUtils.js";
@@ -78,4 +80,55 @@ describe("clipboardUtils", () => {
expect(isWindowSecureContext()).toBe(false);
Object.defineProperty(window, "isSecureContext", { configurable: true, value: prev });
});
+
+ it("copyImageBlobToClipboard writes ClipboardItem when API is available", async () => {
+ const write = vi.fn(() => Promise.resolve());
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: { write },
+ });
+ vi.stubGlobal(
+ "ClipboardItem",
+ class ClipboardItem {
+ constructor(items) {
+ this.items = items;
+ }
+ },
+ );
+ const prev = window.isSecureContext;
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
+ try {
+ expect(canUseAsyncClipboardImageWrite()).toBe(true);
+ const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" });
+ const ok = await copyImageBlobToClipboard(blob);
+ expect(ok).toBe(true);
+ expect(write).toHaveBeenCalledTimes(1);
+ const arg = write.mock.calls[0][0];
+ expect(Array.isArray(arg)).toBe(true);
+ expect(arg[0]).toBeInstanceOf(ClipboardItem);
+ } finally {
+ Object.defineProperty(window, "isSecureContext", {
+ configurable: true,
+ value: prev,
+ });
+ }
+ });
+
+ it("copyImageBlobToClipboard returns false without clipboard write API", async () => {
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: { writeText: vi.fn() },
+ });
+ const prev = window.isSecureContext;
+ Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
+ try {
+ const blob = new Blob([new Uint8Array([1])], { type: "image/png" });
+ expect(await copyImageBlobToClipboard(blob)).toBe(false);
+ } finally {
+ Object.defineProperty(window, "isSecureContext", {
+ configurable: true,
+ value: prev,
+ });
+ }
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────